--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e06f20f7b273562a8494bd07c029c9941ef985c9
Parents : 97235d4
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T02:27:23-05:00
feat(telemetry): add telemetry history modal and battery chart components for enhanced telemetry data visualization
Changes
4 files changed, 465 insertions(+), 0 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index d910532c..8c6b8f79 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -490,6 +490,22 @@ export default {
break;
}
case "lxm.ingest_uri.result": {
+ if (json.ingest_type === "map_view" && json.map_query) {
+ const mq = json.map_query;
+ const query = {
+ lat: String(mq.lat),
+ lon: String(mq.lon),
+ zoom: String(mq.zoom),
+ };
+ if (mq.layers) {
+ query.layers = mq.layers;
+ }
+ if (mq.label) {
+ query.label = mq.label;
+ }
+ await this.$router.push({ name: "map", query });
+ break;
+ }
if (json.status === "success") {
this.ingestUri = "";
if (json.ingest_type === "lxma_contact" && json.destination_hash) {
diff --git a/meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue b/meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue
new file mode 100644
index 00000000..98df0942
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue
@@ -0,0 +1,246 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <div v-if="spec" class="space-y-2">
+ <div class="flex items-start justify-between gap-2 border-b border-gray-200/70 pb-2 dark:border-zinc-700/70">
+ <div class="flex items-center gap-2 min-w-0">
+ <MaterialDesignIcon icon-name="battery-high" class="size-4 shrink-0 text-sky-600 dark:text-sky-400" />
+ <span
+ class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-zinc-400 truncate"
+ >
+ {{ $t("messages.telemetry_battery_trend") }}
+ </span>
+ </div>
+ <span class="text-[11px] tabular-nums font-medium text-gray-600 dark:text-zinc-300 shrink-0">
+ {{ $t("messages.telemetry_battery_range", { from: firstPct, to: lastPct }) }}
+ </span>
+ </div>
+ <div class="flex gap-1.5">
+ <div
+ class="flex flex-col justify-between text-[9px] font-medium text-gray-400 dark:text-zinc-500 pr-0.5 pt-0.5 pb-6 w-5 text-right tabular-nums select-none"
+ >
+ <span>100</span>
+ <span>75</span>
+ <span>50</span>
+ <span>25</span>
+ <span>0</span>
+ </div>
+ <div
+ ref="chartWrap"
+ class="relative flex-1 min-w-0 cursor-crosshair touch-none"
+ @mouseleave="onChartLeave"
+ @mousemove="onChartMove"
+ @touchstart="onChartTouch"
+ @touchmove.prevent="onChartTouch"
+ @touchend="onChartLeave"
+ >
+ <svg
+ ref="chartSvg"
+ class="block h-44 w-full text-sky-600 dark:text-sky-400"
+ :viewBox="spec.viewBox"
+ role="img"
+ :aria-label="$t('messages.telemetry_battery_trend')"
+ >
+ <defs>
+ <linearGradient :id="spec.gradientId" x1="0%" y1="0%" x2="0%" y2="100%">
+ <stop offset="0%" stop-color="rgb(14, 165, 233)" stop-opacity="0.38" />
+ <stop offset="55%" stop-color="rgb(56, 189, 248)" stop-opacity="0.14" />
+ <stop offset="100%" stop-color="rgb(125, 211, 252)" stop-opacity="0.03" />
+ </linearGradient>
+ <linearGradient :id="spec.strokeGradientId" x1="0%" y1="0%" x2="100%" y2="0%">
+ <stop offset="0%" stop-color="rgb(2, 132, 199)" />
+ <stop offset="100%" stop-color="rgb(14, 165, 233)" />
+ </linearGradient>
+ </defs>
+ <g class="text-gray-200/90 dark:text-zinc-700/90" stroke="currentColor" stroke-width="0.5">
+ <line
+ v-for="(g, idx) in spec.gridLines"
+ :key="idx"
+ :x1="0"
+ :x2="100"
+ :y1="g.y1"
+ :y2="g.y2"
+ vector-effect="non-scaling-stroke"
+ />
+ </g>
+ <path :d="spec.areaPath" :fill="`url(#${spec.gradientId})`" stroke="none" />
+ <path
+ :d="spec.linePath"
+ fill="none"
+ :stroke="`url(#${spec.strokeGradientId})`"
+ stroke-width="1.25"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ vector-effect="non-scaling-stroke"
+ />
+ <circle
+ :cx="spec.first.x"
+ :cy="spec.first.y"
+ r="1.5"
+ fill="white"
+ class="dark:fill-zinc-900"
+ stroke="rgb(2, 132, 199)"
+ stroke-width="1"
+ vector-effect="non-scaling-stroke"
+ />
+ <circle
+ :cx="spec.last.x"
+ :cy="spec.last.y"
+ r="2"
+ fill="rgb(14, 165, 233)"
+ stroke="white"
+ stroke-width="0.85"
+ class="dark:stroke-zinc-900"
+ vector-effect="non-scaling-stroke"
+ />
+ <g v-if="hover">
+ <line
+ :x1="hover.gx"
+ :x2="hover.gx"
+ :y1="spec.layout.PT"
+ :y2="spec.plotBottom"
+ stroke="currentColor"
+ class="text-sky-600/70 dark:text-sky-400/80"
+ stroke-width="0.65"
+ stroke-dasharray="3 2.5"
+ vector-effect="non-scaling-stroke"
+ stroke-linecap="round"
+ />
+ <circle
+ :cx="hover.gx"
+ :cy="hover.gy"
+ r="2"
+ fill="white"
+ class="dark:fill-zinc-900"
+ stroke="rgb(2, 132, 199)"
+ stroke-width="1"
+ vector-effect="non-scaling-stroke"
+ />
+ </g>
+ </svg>
+ <div
+ v-if="hover"
+ class="pointer-events-none absolute z-10 rounded-md border border-sky-200/90 bg-white/95 px-2 py-1 text-[10px] font-semibold tabular-nums text-sky-900 shadow-md dark:border-sky-800 dark:bg-zinc-900/95 dark:text-sky-100"
+ :style="hoverTipStyle"
+ >
+ <div>{{ hover.pct }}%</div>
+ <div class="font-normal text-gray-500 dark:text-zinc-400">{{ hover.timeLabel }}</div>
+ </div>
+ <div
+ class="pointer-events-none absolute inset-x-0 bottom-0 flex h-6 justify-between px-0.5 text-[9px] tabular-nums text-gray-400 dark:text-zinc-500"
+ >
+ <span class="truncate max-w-[45%]">{{ startLabel }}</span>
+ <span class="truncate max-w-[45%] text-right">{{ endLabel }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import {
+ buildTelemetryBatteryChartSpec,
+ clampBatteryPercent,
+ interpolateBatteryByTime,
+} from "../../../js/telemetryBatteryChartSpec.js";
+import dayjs from "dayjs";
+
+export default {
+ name: "TelemetryBatteryChart",
+ components: { MaterialDesignIcon },
+ props: {
+ samples: {
+ type: Array,
+ required: true,
+ },
+ idSuffix: {
+ type: String,
+ default: "default",
+ },
+ },
+ data() {
+ return {
+ hover: null,
+ };
+ },
+ computed: {
+ spec() {
+ return buildTelemetryBatteryChartSpec(this.samples, this.idSuffix);
+ },
+ firstPct() {
+ if (!this.samples.length) return 0;
+ return Math.round(this.samples[0].y);
+ },
+ lastPct() {
+ if (!this.samples.length) return 0;
+ return Math.round(this.samples[this.samples.length - 1].y);
+ },
+ startLabel() {
+ if (!this.samples.length) return "";
+ return dayjs(this.samples[0].x * 1000).format("MMM D, HH:mm");
+ },
+ endLabel() {
+ if (!this.samples.length) return "";
+ return dayjs(this.samples[this.samples.length - 1].x * 1000).format("MMM D, HH:mm");
+ },
+ hoverTipStyle() {
+ if (!this.hover) return {};
+ return {
+ left: `${this.hover.tipLeft}px`,
+ top: `${this.hover.tipTop}px`,
+ transform: "translate(-50%, calc(-100% - 6px))",
+ };
+ },
+ },
+ methods: {
+ svgPointFromClient(clientX, clientY) {
+ const svg = this.$refs.chartSvg;
+ if (!svg) return null;
+ const ctm = svg.getScreenCTM();
+ if (!ctm) return null;
+ return new DOMPoint(clientX, clientY).matrixTransform(ctm.inverse());
+ },
+ updateHoverFromClient(clientX, clientY) {
+ const spec = this.spec;
+ if (!spec) return;
+ const p = this.svgPointFromClient(clientX, clientY);
+ if (!p) return;
+ const { PL, PR, PT, PB, minX, maxX } = spec.layout;
+ let gx = p.x;
+ if (gx < PL) gx = PL;
+ if (gx > PR) gx = PR;
+ const rangeX = maxX - minX || 1;
+ const ts = minX + ((gx - PL) / (PR - PL)) * rangeX;
+ const { y } = interpolateBatteryByTime(this.samples, ts);
+ const gy = PT + (1 - clampBatteryPercent(y) / 100) * (PB - PT);
+ const wrap = this.$refs.chartWrap;
+ let tipLeft = 0;
+ let tipTop = 0;
+ if (wrap) {
+ const r = wrap.getBoundingClientRect();
+ tipLeft = Math.min(Math.max(clientX - r.left, 10), Math.max(10, r.width - 10));
+ tipTop = clientY - r.top;
+ }
+ this.hover = {
+ gx,
+ gy,
+ pct: Math.round(y),
+ timeLabel: dayjs(ts * 1000).format("MMM D, HH:mm"),
+ tipLeft,
+ tipTop,
+ };
+ },
+ onChartMove(ev) {
+ this.updateHoverFromClient(ev.clientX, ev.clientY);
+ },
+ onChartTouch(ev) {
+ const t = ev.touches[0];
+ if (t) this.updateHoverFromClient(t.clientX, t.clientY);
+ },
+ onChartLeave() {
+ this.hover = null;
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue b/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue
new file mode 100644
index 00000000..ce210978
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue
@@ -0,0 +1,72 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <div class="p-3 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30">
+ <div class="flex justify-between items-start mb-2">
+ <span
+ class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-zinc-400"
+ >
+ {{ item.is_outbound ? $t("messages.telemetry_label_sent") : $t("messages.telemetry_label_received") }}
+ </span>
+ <span class="text-[10px] text-gray-400">{{ formatTimeAgo(item.lxmf_message.created_at) }}</span>
+ </div>
+
+ <div v-if="item.lxmf_message.fields?.telemetry?.location" class="flex items-center gap-2 mb-2">
+ <button
+ type="button"
+ class="flex items-center gap-2 text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline"
+ @click="$emit('location-click', item.lxmf_message.fields.telemetry.location)"
+ >
+ <MaterialDesignIcon icon-name="map-marker" class="size-4" />
+ {{ item.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
+ {{ item.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
+ </button>
+ </div>
+
+ <div
+ v-if="item.lxmf_message.fields?.telemetry"
+ class="flex flex-wrap gap-3 text-[10px] text-gray-500 dark:text-zinc-400"
+ >
+ <span v-if="item.lxmf_message.fields.telemetry.battery" class="flex items-center gap-1">
+ <MaterialDesignIcon icon-name="battery" class="size-3" />
+ {{
+ $t("messages.telemetry_battery_level", {
+ percent: item.lxmf_message.fields.telemetry.battery.charge_percent,
+ })
+ }}
+ </span>
+ <span v-if="item.lxmf_message.fields.telemetry.physical_link" class="flex items-center gap-1">
+ <MaterialDesignIcon icon-name="antenna" class="size-3" />
+ {{ $t("messages.telemetry_snr_db", { snr: item.lxmf_message.fields.telemetry.physical_link.snr }) }}
+ </span>
+ </div>
+
+ <div
+ v-if="item.lxmf_message.fields?.commands?.some((c) => c['0x01'])"
+ class="flex items-center gap-2 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1"
+ >
+ <MaterialDesignIcon icon-name="crosshairs-question" class="size-3" />
+ <span>{{ $t("messages.telemetry_location_request") }}</span>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "TelemetryHistoryListItem",
+ components: { MaterialDesignIcon },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ formatTimeAgo: {
+ type: Function,
+ required: true,
+ },
+ },
+ emits: ["location-click"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue b/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue
new file mode 100644
index 00000000..f7f50327
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue
@@ -0,0 +1,131 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <Teleport to="body">
+ <div
+ v-if="modelValue"
+ class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
+ @click.self="close"
+ >
+ <div
+ class="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]"
+ >
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
+ <div class="flex items-center gap-2 min-w-0">
+ <MaterialDesignIcon icon-name="satellite-variant" class="size-6 text-blue-500 shrink-0" />
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white truncate">
+ {{ $t("messages.telemetry_history_modal_title") }}
+ </h3>
+ </div>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors shrink-0"
+ @click="close"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-6" />
+ </button>
+ </div>
+ <div class="flex-1 overflow-y-auto p-4 space-y-3">
+ <div v-if="telemetryItems.length === 0" class="text-center py-8 text-gray-400 dark:text-zinc-500">
+ {{ $t("messages.telemetry_history_empty") }}
+ </div>
+ <TelemetryHistoryListItem
+ v-for="item in telemetryItems"
+ :key="item.lxmf_message.hash"
+ :item="item"
+ :format-time-ago="formatTimeAgo"
+ @location-click="$emit('location-click', $event)"
+ />
+ </div>
+ <div
+ class="flex flex-col gap-3 border-t border-gray-100 bg-gray-50/40 px-6 py-4 dark:border-zinc-800 dark:bg-zinc-900/25"
+ >
+ <TelemetryBatteryChart
+ v-if="batteryHistory.length > 1"
+ :samples="batteryHistory"
+ :id-suffix="gradientIdSuffix"
+ />
+
+ <div
+ class="flex w-full items-center justify-between gap-3"
+ :class="
+ batteryHistory.length > 1 ? 'border-t border-gray-200/80 pt-3 dark:border-zinc-700/80' : ''
+ "
+ >
+ <label class="flex items-center gap-2 cursor-pointer group min-w-0">
+ <input
+ :checked="showTelemetryInChat"
+ type="checkbox"
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
+ @change="onShowTelemetryChange"
+ />
+ <span
+ class="text-xs font-medium text-gray-600 dark:text-zinc-400 group-hover:text-gray-900 dark:group-hover:text-zinc-200"
+ >{{ $t("messages.telemetry_show_in_chat") }}</span
+ >
+ </label>
+ <button
+ type="button"
+ class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-sm shrink-0"
+ @click="close"
+ >
+ {{ $t("messages.telemetry_history_done") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </Teleport>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import TelemetryBatteryChart from "./TelemetryBatteryChart.vue";
+import TelemetryHistoryListItem from "./TelemetryHistoryListItem.vue";
+import { batteryHistoryFromTelemetryItems } from "../../../js/telemetryBatteryChartSpec.js";
+
+export default {
+ name: "TelemetryHistoryModal",
+ components: {
+ MaterialDesignIcon,
+ TelemetryBatteryChart,
+ TelemetryHistoryListItem,
+ },
+ props: {
+ modelValue: {
+ type: Boolean,
+ default: false,
+ },
+ telemetryItems: {
+ type: Array,
+ default: () => [],
+ },
+ showTelemetryInChat: {
+ type: Boolean,
+ default: false,
+ },
+ formatTimeAgo: {
+ type: Function,
+ required: true,
+ },
+ gradientIdSuffix: {
+ type: String,
+ default: "peer",
+ },
+ },
+ emits: ["update:modelValue", "update:showTelemetryInChat", "location-click"],
+ computed: {
+ batteryHistory() {
+ return batteryHistoryFromTelemetryItems(this.telemetryItems);
+ },
+ },
+ methods: {
+ close() {
+ this.$emit("update:modelValue", false);
+ },
+ onShowTelemetryChange(e) {
+ this.$emit("update:showTelemetryInChat", e.target.checked);
+ },
+ },
+};
+</script>
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────